1"use client";
2
3
22import { useState } from "react";
23import { useParams } from "next/navigation";
24import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
25import { Loader2, Plus, Trash2, Users } from "lucide-react";
26import { toast } from "@/stores/ui-store";
27import { apiFetch } from "@/lib/api";
28import { useWorkspace, useWorkspaceMembers } from "@/hooks/use-workspace";
29import { SettingsShell, Section, Field, inputCls } from "@/components/settings/settings-shell";
30
31interface Team {
32 id: string;
33 name: string;
34 slug?: string | null;
35 description?: string | null;
36 color?: string | null;
37 isPrivate?: boolean;
38
39 memberCount?: number;
40}
41function unwrapTeams(body: unknown): Team[] {
42 const b = body as { data?: Team[] } | Team[] | null;
43 if (Array.isArray(b)) return b;
44 if (b && typeof b === "object" && Array.isArray((b as { data?: Team[] }).data)) return (b as { data: Team[] }).data;
45 return [];
46}
47
48export default function SettingsTeamsPage() {
49 const params = useParams<{ workspace: string }>();
50 const slugOrId = params?.workspace ?? "";
51 const { data: ws, isLoading } = useWorkspace(slugOrId);
52 const wid = ws?.id ?? "";
53 const qc = useQueryClient();
54
55 const teams = useQuery({
56 queryKey: ["workspaces", wid, "teams"],
57 queryFn: async (): Promise<Team[]> => unwrapTeams(await apiFetch(`/workspaces/${wid}/teams`)),
58 enabled: !!wid
59 });
60
61 const [name, setName] = useState("");
62 const [color, setColor] = useState("");
63 const create = useMutation({
64 mutationFn: (input: { name: string; color?: string }) =>
65 apiFetch(`/workspaces/${wid}/teams`, { method: "POST", body: input }),
66 onSuccess: () => {
67 qc.invalidateQueries({ queryKey: ["workspaces", wid, "teams"] });
68 setName("");
69 setColor("");
70 }
71 });
72
73 const role = (ws?.role ?? "").toLowerCase();
74 const canManage = role === "owner" || role === "admin";
75
76 const submitCreate = () => {
77 if (!name.trim()) return;
78 create.mutate(
79 { name: name.trim(), color: color || undefined },
80 { onSuccess: () => toast("success", "Team created"), onError: (e) => toast("error", "Create failed", (e as Error).message) }
81 );
82 };
83
84 return (
85 <SettingsShell title="Teams" description="Teamspaces organize shared work in the sidebar." active="teams">
86 {isLoading || !ws ? (
87 <p className="text-sm text-muted-foreground">Loading…</p>
88 ) : (
89 <div className="space-y-5">
90 {canManage && (
91 <Section title="New team">
92 <div className="flex items-center gap-1.5">
93 <input className={`min-w-0 flex-1 ${inputCls}`} placeholder="Team name" value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submitCreate()} />
94 <input className={`${inputCls} max-w-20`} placeholder="#hex" value={color} onChange={(e) => setColor(e.target.value)} aria-label="Color" />
95 <button type="button" className="inline-flex items-center gap-1 rounded-md bg-primary px-2.5 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50" disabled={!name.trim() || create.isPending} onClick={submitCreate}>
96 {create.isPending ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
97 Create
98 </button>
99 </div>
100 </Section>
101 )}
102
103 <Section title={`Teams (${teams.data?.length ?? 0})`}>
104 {teams.isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
105 <ul className="space-y-3">
106 {(teams.data ?? []).map((t) => (
107 <TeamCard key={t.id} team={t} workspaceId={wid} canManage={canManage} onManage={() => qc.invalidateQueries({ queryKey: ["workspaces", wid, "teams"] })} />
108 ))}
109 </ul>
110 {teams.data && teams.data.length === 0 && (
111 <p className="text-sm text-muted-foreground">No teams yet. Create one to organize shared work.</p>
112 )}
113 </Section>
114 </div>
115 )}
116 </SettingsShell>
117 );
118}
119
120function TeamCard({ team, workspaceId, canManage, onManage }: { team: Team; workspaceId: string; canManage: boolean; onManage: () => void }) {
121 const [editing, setEditing] = useState(false);
122 const [name, setName] = useState(team.name);
123 const [description, setDescription] = useState(team.description ?? "");
124 const [addingId, setAddingId] = useState("");
125 const [busy, setBusy] = useState(false);
126
127 const members = useWorkspaceMembers(workspaceId);
128 const allMembers = (members.data ?? []) as { userId: string; user: { id: string; name: string; email: string; image?: string | null } }[];
129
130 const save = async () => {
131 setBusy(true);
132 try {
133 await apiFetch(`/workspaces/${workspaceId}/teams/${team.id}`, {
134 method: "PATCH",
135 body: { name: name.trim(), description: description || null }
136 });
137 toast("success", "Team updated");
138 setEditing(false);
139 onManage();
140 } catch (e) {
141 toast("error", "Update failed", (e as Error).message);
142 } finally {
143 setBusy(false);
144 }
145 };
146
147 const remove = async () => {
148 setBusy(true);
149 try {
150 await apiFetch(`/workspaces/${workspaceId}/teams/${team.id}`, { method: "DELETE" });
151 toast("success", "Team deleted");
152 onManage();
153 } catch (e) {
154 toast("error", "Delete failed", (e as Error).message);
155 setBusy(false);
156 }
157 };
158
159 const addMember = async () => {
160 if (!addingId) return;
161 setBusy(true);
162 try {
163 await apiFetch(`/workspaces/${workspaceId}/teams/${team.id}/members`, { method: "POST", body: { userId: addingId } });
164 toast("success", "Member added");
165 setAddingId("");
166 onManage();
167 } catch (e) {
168 toast("error", "Add failed", (e as Error).message);
169 } finally {
170 setBusy(false);
171 }
172 };
173
174 return (
175 <li className="rounded-lg border border-border p-3">
176 <div className="flex items-center gap-2.5">
177 <span className="flex size-7 items-center justify-center rounded-md bg-muted" aria-hidden>
178 <Users className="size-4 text-muted-foreground" />
179 </span>
180 {editing ? (
181 <input className={`min-w-0 flex-1 ${inputCls}`} value={name} onChange={(e) => setName(e.target.value)} aria-label="Team name" />
182 ) : (
183 <p className="min-w-0 flex-1 truncate text-sm font-medium">{team.name}</p>
184 )}
185 {!editing && canManage && (
186 <button type="button" className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted" onClick={() => setEditing(true)}>
187 Edit
188 </button>
189 )}
190 {!editing && canManage && (
191 <button type="button" className="rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive" onClick={remove} disabled={busy} aria-label={`Delete ${team.name}`}>
192 <Trash2 className="size-4" />
193 </button>
194 )}
195 </div>
196
197 {editing && (
198 <div className="mt-2 space-y-2">
199 <Field label="Description">
200 <input className={inputCls} value={description} onChange={(e) => setDescription(e.target.value)} />
201 </Field>
202 <div className="flex gap-1.5">
203 <button type="button" className="rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-white disabled:opacity-50" onClick={save} disabled={busy}>
204 {busy ? "Saving…" : "Save"}
205 </button>
206 <button type="button" className="rounded-md border px-2.5 py-1 text-xs" onClick={() => setEditing(false)}>
207 Cancel
208 </button>
209 </div>
210 </div>
211 )}
212
213 {team.description && !editing ? <p className="mt-1 text-xs text-muted-foreground">{team.description}</p> : null}
214
215 {}
216 <div className="mt-2 border-t border-border pt-2">
217 <p className="mb-1.5 text-xs font-medium text-muted-foreground">
218 {team.memberCount != null ? `${team.memberCount} member${team.memberCount === 1 ? "" : "s"}` : "Members"}
219 </p>
220 {canManage ? (
221 <div className="flex items-center gap-1.5">
222 <select className={`min-w-0 flex-1 rounded-md border border-input bg-background px-1.5 py-1 text-xs outline-none`} value={addingId} onChange={(e) => setAddingId(e.target.value)} aria-label="Add member">
223 <option value="">Add member…</option>
224 {allMembers.map((m) => (
225 <option key={m.userId} value={m.userId}>
226 {m.user.name}
227 </option>
228 ))}
229 </select>
230 <button type="button" className="rounded-md border px-2 py-1 text-xs disabled:opacity-50" onClick={addMember} disabled={!addingId || busy}>
231 Add
232 </button>
233 </div>
234 ) : (
235 <p className="text-xs text-muted-foreground">
236 The teams API doesn't return the individual member list yet, so members can't be displayed or removed here.
237 </p>
238 )}
239 </div>
240 </li>
241 );
242}
243